[feat] #71 마이페이지 UI 수정 및 API 연동#79
Conversation
There was a problem hiding this comment.
Actionable comments posted: 14
🤖 Fix all issues with AI agents
In `@app/build.gradle.kts`:
- Around line 33-40: The signingConfigs block uses properties["KEY"].toString()
which yields the literal "null" when keys are absent; update the release signing
config inside create("release") to use safe calls and explicit null handling for
storePassword, keyAlias and keyPassword (e.g.,
properties["STORE_PASSWORD"]?.toString() ?: error("STORE_PASSWORD not set") or
requireNotNull(properties["STORE_PASSWORD"]?.toString()) { "STORE_PASSWORD
missing" }) so missing local.properties entries fail fast with a clear error
instead of producing the "null" string.
In
`@core/common/src/main/java/com/neki/android/core/common/permission/NotificationPermissionManager.kt`:
- Around line 12-14: Rename/adjust the logic in
NotificationPermissionManager.isGrantedNotificationPermission to differentiate
runtime permission from notification enabled state: on Android S+ (SDK_INT >=
33) use ContextCompat.checkSelfPermission(context,
Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED to
determine POST_NOTIFICATIONS consent, otherwise fall back to
NotificationManagerCompat.from(context).areNotificationsEnabled(); keep the same
method name but ensure it checks permission for API 33+ and uses
areNotificationsEnabled() for older APIs so both concepts are handled.
In
`@core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.kt`:
- Around line 18-21: When calling UploadProfileImageUseCase from
MyPageViewModel, determine the actual image MIME type from the provided Uri via
ContentResolver.getType(uri) (or ContentResolver.openInputStream+probe if
needed), map that MIME (e.g. "image/png", "image/jpeg", "image/webp") to the
domain ContentType enum, and pass the resulting ContentType into
UploadProfileImageUseCase.invoke(uri, contentType) instead of relying on the
default ContentType.JPEG; update the call site in MyPageViewModel to compute and
supply this contentType so files are named and uploaded with the correct image
format.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt`:
- Around line 40-42: The call to context.packageManager.getPackageInfo(...) in
MyPageScreen's appVersion remember block uses the deprecated API for API 33+;
update the retrieval to handle SDK >= 33 by calling
PackageManager.getPackageInfo with PackageManager.PackageInfoFlags.of(0L) and
fall back to the deprecated overload with `@Suppress`("DEPRECATION") for older
SDKs, or extract this logic into a helper/extension (e.g., a function like
getVersionName(context) used by MyPageScreen) to centralize compatibility
handling.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt`:
- Around line 182-196: In signOut (MyPageViewModel.signOut) ensure the token
cache is invalidated on successful sign-out by invoking
AuthCacheManager.invalidateTokenCache() in the onSuccess block (after
tokenRepository.clearTokens()); update the onSuccess handler that currently
calls tokenRepository.clearTokens() and
postSideEffect(MyPageEffect.NavigateToLogin) to also call
AuthCacheManager.invalidateTokenCache() so the in-memory cache is cleared the
same way logout handles it.
- Around line 148-151: The parallel API calls built in the buildList block (the
async calls to userRepository.updateUserInfo and uploadProfileImageUseCase,
followed by awaitAll()) currently ignore per-call failures; change each async to
wrap the call in runCatching (e.g., async { runCatching {
userRepository.updateUserInfo(...) } } and async { runCatching {
uploadProfileImageUseCase(...) } }), awaitAll() to get List<Result<...>>, then
iterate the results to handle failures (log, emit UI error state or show a
user-facing message) and handle successes accordingly; refer to the async blocks
inside the buildList, the calls userRepository.updateUserInfo and
uploadProfileImageUseCase, and the awaitAll() usage in MyPageViewModel.kt.
- Around line 177-180: The logout implementation in MyPageViewModel currently
clears tokens via tokenRepository.clearTokens() but does not invalidate the Ktor
token cache; call AuthCacheManager.invalidateTokenCache() before posting the
NavigateToLogin effect so cached bearer tokens are wiped; update the logout
function to invoke AuthCacheManager.invalidateTokenCache() (or its
singleton/instance equivalent used in the codebase) then
tokenRepository.clearTokens() and finally
postSideEffect(MyPageEffect.NavigateToLogin).
- Around line 171-174: The onFailure handler in the MyPageViewModel currently
resets state and calls postSideEffect(MyPageEffect.NavigateBack), which hides
the screen with no user feedback; change the onFailure block to reset isLoading
and selectedProfileImage (keep the reduce call) but replace NavigateBack with an
error feedback effect (e.g., postSideEffect(MyPageEffect.ShowError(message)) or
similar) so the UI shows an error toast/dialog and stays on the screen; if
MyPageEffect lacks a ShowError variant, add one (e.g., ShowError(String) or
ShowError(`@StringRes` int)) and handle it in the view to display the error
message instead of navigating back.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/PermissionScreen.kt`:
- Around line 70-77: The launcher callback in PermissionScreen.kt computes
isGranted with permissions.values.any { it }, which diverges from the
manager-based logic used in checkPermissions(); update the permissionLauncher
callback to determine the granted state the same way as checkPermissions():
first compute the permission type using
CameraPermissionManager.CAMERA_PERMISSION,
LocationPermissionManager.LOCATION_PERMISSIONS and
NotificationPermissionManager.NOTIFICATION_PERMISSION (as you already do), then
for each case call the same manager check used in checkPermissions() (e.g.,
NotificationPermissionManager.areNotificationsEnabled() for notifications and
the corresponding camera/location manager methods for those permissions) and
pass that boolean into
viewModel.store.onIntent(MyPageIntent.UpdatePermissionState(permission,
isGranted)).
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/SettingProfileImage.kt`:
- Around line 57-61: The Icon currently uses contentDescription = null which
makes the editable icon inaccessible; update the Icon (the composable that uses
Modifier.noRippleClickableSingle and onClickEdit) to provide a meaningful
contentDescription (e.g., use a string resource like R.string.edit_profile_image
or a descriptive literal "Edit profile image"/"프로필 사진 편집") so screen readers
announce the action; ensure you reference the same symbol (Icon with
Modifier.noRippleClickableSingle and onClickEdit) and use a localized string
resource rather than a hard-coded string.
- Around line 39-46: The AsyncImage in SettingProfileImage.kt uses profileImage
(a URL) as its model but doesn't specify placeholder or error images, so
failures leave the UI blank; import painterResource and pass painterResource(id
= R.drawable.image_empty_profile_image) to AsyncImage's placeholder and error
parameters (while keeping model = profileImage ?:
R.drawable.image_empty_profile_image) so the default drawable is shown on
load/failure and ensure the painterResource import is added at the top of the
file.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt`:
- Line 74: 현재 textFieldState =
rememberTextFieldState(uiState.userInfo.nickname)는 초기 composition에서만 초기값을 잡아
네트워크로 닉네임이 바뀌어도 반영되지 않습니다; 수정은 두 가지 중 하나로 하세요: 1) remember의 key로
uiState.userInfo.nickname을 사용해 textFieldState를 재생성(예:
remember(uiState.userInfo.nickname) {
rememberTextFieldState(uiState.userInfo.nickname) })하거나 2) 텍스트 값을 동기화하는 방식으로
LaunchedEffect(uiState.userInfo.nickname) 내부에서 textFieldState의 업데이트 메서드(예:
setText 또는 해당하는 변경 API)를 호출해 외부 닉네임 변경 시 TextField 상태를 갱신하세요; 대상 심볼:
rememberTextFieldState, textFieldState, uiState.userInfo.nickname,
LaunchedEffect.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt`:
- Around line 62-65: When calling SettingProfileImage, ensure an empty profile
URL is converted to null to avoid attempting to load an empty model: replace the
direct uiState.userInfo.profileImageUrl argument with a null-safe expression
(e.g., use takeIf { it.isNotBlank() }/ifBlank { null }) so SettingProfileImage
receives null when the string is empty; update the call site where
SettingProfileImage is invoked (referencing SettingProfileImage and
uiState.userInfo.profileImageUrl).
In `@gradle/libs.versions.toml`:
- Around line 40-41: Update the OSS Licenses dependency versions in
gradle/libs.versions.toml by changing the ossLicensesLib and ossLicensesPlugin
values to the latest releases; specifically set ossLicensesLib to 17.4.0 and
ossLicensesPlugin to 0.10.10 so the entries for ossLicensesLib and
ossLicensesPlugin reflect the updated versions.
🧹 Nitpick comments (10)
feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/const/ServiceInfoMenu.kt (1)
3-18: 하드코딩된 문자열은 리소스로 분리하세요.UI 문자열이 코드에 직접 박혀 있어 로컬라이징/리소스 관리가 어렵습니다.
@StringRes로 바꾸고strings.xml에 이동하는 방식이 더 안드로이드 관례에 맞습니다.♻️ 제안 변경
+import androidx.annotation.StringRes +import com.neki.android.feature.mypage.impl.R + enum class ServiceInfoMenu( - val text: String, + `@StringRes` val textRes: Int, val url: String, ) { INQUIRY( - text = "Neki에 문의하기", + textRes = R.string.service_info_inquiry, url = "https://tally.so/r/obGpRX", ), TERMS_OF_SERVICE( - text = "이용약관", + textRes = R.string.service_info_terms_of_service, url = "https://lydian-tip-26b.notion.site/2ee0d9441db0807c8684ce3e2d4b8aca?source=copy_link", ), PRIVACY_POLICY( - text = "개인정보 처리방침", + textRes = R.string.service_info_privacy_policy, url = "https://lydian-tip-26b.notion.site/2ee0d9441db0807cb850f78145db6dd3?pvs=74", ), }
strings.xml에 위 3개 문자열을 추가해 주세요.feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionItem.kt (1)
69-69: 하드코딩된 문자열을 string resource로 추출하는 것을 고려해주세요."앱 버전 정보" 문자열이 하드코딩되어 있습니다. 향후 다국어 지원(i18n)을 위해
strings.xml리소스로 추출하는 것이 좋습니다.♻️ 제안하는 변경
`@Composable` fun SectionVersionItem( appVersion: String, ) { SectionItem( - text = "앱 버전 정보", + text = stringResource(R.string.app_version_info), trailingContent = {
strings.xml에 추가:<string name="app_version_info">앱 버전 정보</string>feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/model/SelectedProfileImage.kt (1)
5-8: 프로필 이미지 상태 모델이 잘 설계되었습니다PR에서 언급하신 세 가지 상태를 잘 표현하고 있습니다:
NoChange: 변경 없음Selected(uri): 사용자가 새 이미지 선택Selected(null): 기본 프로필로 변경상태의 의도를 더 명확히 하기 위해 KDoc 주석을 추가하는 것을 고려해 보세요:
📝 문서화 제안
+/** + * 프로필 이미지 선택 상태를 나타내는 sealed interface. + */ sealed interface SelectedProfileImage { + /** 프로필 이미지 변경 없음 */ data object NoChange : SelectedProfileImage + /** 프로필 이미지 선택됨. uri가 null이면 기본 프로필로 변경을 의미 */ data class Selected(val uri: Uri?) : SelectedProfileImage }feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/const/NekiPermission.kt (1)
6-22: dialogContent 추가가 적절합니다권한별 다이얼로그 메시지를 enum에 중앙화한 점이 좋습니다.
다국어 지원이 필요한 경우, 하드코딩된 문자열 대신 string resource를 사용하는 것을 고려해 보세요. 현재 앱이 한국어만 지원한다면 현 상태로도 충분합니다.
app/proguard-rules.pro (1)
15-16: 프로젝트 전체 클래스에 대한 keep 규칙이 너무 광범위합니다.
com.neki.android.**전체를 keep하면 R8의 코드 축소 및 난독화 이점이 거의 사라집니다. 직렬화 클래스나 리플렉션이 필요한 특정 패키지만 타겟팅하는 것이 좋습니다.예시:
-# ======================== -# Project Classes -# ======================== --keep class com.neki.android.** { *; } --keepclassmembers class com.neki.android.** { *; } +# ======================== +# Project Classes - Keep only serializable/reflection-based classes +# ======================== +-keep class com.neki.android.**.model.** { *; } +-keep class com.neki.android.**.dto.** { *; } +-keep class com.neki.android.**.response.** { *; } +-keep class com.neki.android.**.request.** { *; }feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt (1)
46-57: 미처리 Effect에 대한 명시적 처리 권장
NavigateBack과NavigateToLogin이 빈 핸들러로 처리되고 있습니다. 이 Effect들이 자식 화면에서만 처리되어야 하는 것이라면, 주석으로 의도를 명시하면 유지보수에 도움이 됩니다.else -> {}브랜치는 새로운 Effect 추가 시 누락을 감지하기 어렵게 만들 수 있습니다.feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/ProfileCard.kt (1)
41-48: AsyncImage에 placeholder/error 처리 추가 권장네트워크 이미지 로딩 중이나 실패 시 사용자 경험을 위해
placeholder와error파라미터 추가를 고려해주세요. 현재ifEmpty폴백은 URL이 빈 문자열일 때만 동작하며, 로딩 중이나 네트워크 오류 시에는 빈 영역이 표시될 수 있습니다.💡 placeholder/error 처리 예시
AsyncImage( modifier = Modifier .size(78.dp) .clip(CircleShape), model = profileImageUrl.ifEmpty { R.drawable.image_empty_profile_image }, contentDescription = null, contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.image_empty_profile_image), + error = painterResource(R.drawable.image_empty_profile_image), )feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt (3)
28-28: Deprecated import 경로 사용 중입니다.
androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel은 deprecated되었습니다.androidx.hilt.navigation.compose.hiltViewModel을 사용해야 합니다.🔧 권장 수정
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.hilt.navigation.compose.hiltViewModel
64-65: 가시성 불일치:EditProfileRoute는 internal이지만EditProfileScreen은 public입니다.모듈 외부에서
EditProfileScreen이 사용되지 않는다면internal로 통일하는 것이 좋습니다.♻️ 권장 수정
`@Composable` -fun EditProfileScreen( +internal fun EditProfileScreen(
163-163: 기본 프로필 선택 시SelectedProfileImage.Selected(null)사용이 의미적으로 모호합니다.
Selected(null)은 "이미지를 선택했지만 null"이라는 의미로 혼란스럽습니다.SelectedProfileImagesealed interface에Default케이스를 추가하는 것을 고려해 보세요.// SelectedProfileImage.kt에 추가 sealed interface SelectedProfileImage { data object NoChange : SelectedProfileImage data object Default : SelectedProfileImage // 기본 프로필로 변경 data class Selected(val uri: Uri) : SelectedProfileImage // null 제거 }
| suspend operator fun invoke( | ||
| uri: Uri?, | ||
| contentType: ContentType = ContentType.JPEG, | ||
| ): Result<Unit> = runSuspendCatching { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find all usages of UploadProfileImageUseCase
rg "UploadProfileImageUseCase" --type=kt -A 10 -B 2Repository: YAPP-Github/27th-App-Team-2-Android
Length of output: 105
🏁 Script executed:
#!/bin/bash
# Find all usages of UploadProfileImageUseCase (without type filter)
rg "UploadProfileImageUseCase" -A 10 -B 2Repository: YAPP-Github/27th-App-Team-2-Android
Length of output: 5496
🏁 Script executed:
#!/bin/bash
# Find the invocation of uploadProfileImageUseCase in MyPageViewModel
rg "uploadProfileImageUseCase" -A 5 -B 2 feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.ktRepository: YAPP-Github/27th-App-Team-2-Android
Length of output: 771
🏁 Script executed:
#!/bin/bash
# Search for MediaUploadRepository and related upload logic
rg "MediaUploadRepository" -A 15 | head -100Repository: YAPP-Github/27th-App-Team-2-Android
Length of output: 12967
🏁 Script executed:
#!/bin/bash
# Find ContentTypeUtil implementation
rg "object ContentTypeUtil|class ContentTypeUtil" -A 20Repository: YAPP-Github/27th-App-Team-2-Android
Length of output: 1108
🏁 Script executed:
#!/bin/bash
# Search for MIME type detection patterns
rg "getMimeType|getType|mimeType" -i --type-listRepository: YAPP-Github/27th-App-Team-2-Android
Length of output: 468
🏁 Script executed:
#!/bin/bash
# Search for MIME type detection patterns
rg "getMimeType|getType|mimeType" -iRepository: YAPP-Github/27th-App-Team-2-Android
Length of output: 61
🏁 Script executed:
#!/bin/bash
# View the complete UploadProfileImageUseCase.kt file
cat -n core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.ktRepository: YAPP-Github/27th-App-Team-2-Android
Length of output: 2096
호출 시 실제 이미지 형식에 맞는 ContentType을 결정하여 전달해야 함
UploadProfileImageUseCase 호출 시 MyPageViewModel에서 contentType 파라미터를 전달하지 않아, contentType이 항상 기본값 ContentType.JPEG로 설정됩니다. 사용자가 PNG 이미지를 선택해도 파일명은 .jpeg 확장자로 생성되고 업로드도 JPEG으로 처리되므로, 실제 이미지 형식과 불일치하는 문제가 발생합니다. Uri로부터 ContentResolver를 통해 실제 MIME 타입을 추출하여 적절한 ContentType을 결정한 후 usecase를 호출하도록 수정이 필요합니다.
🤖 Prompt for AI Agents
In
`@core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.kt`
around lines 18 - 21, When calling UploadProfileImageUseCase from
MyPageViewModel, determine the actual image MIME type from the provided Uri via
ContentResolver.getType(uri) (or ContentResolver.openInputStream+probe if
needed), map that MIME (e.g. "image/png", "image/jpeg", "image/webp") to the
domain ContentType enum, and pass the resulting ContentType into
UploadProfileImageUseCase.invoke(uri, contentType) instead of relying on the
default ContentType.JPEG; update the call site in MyPageViewModel to compute and
supply this contentType so files are named and uploaded with the correct image
format.
| is SelectedProfileImage.Selected -> uiState.selectedProfileImage.uri | ||
| } | ||
|
|
||
| val textFieldState = rememberTextFieldState(uiState.userInfo.nickname) |
There was a problem hiding this comment.
rememberTextFieldState 초기값이 recomposition 시 업데이트되지 않습니다.
rememberTextFieldState(uiState.userInfo.nickname)은 최초 composition 시에만 초기화됩니다. 네트워크에서 사용자 정보를 로드한 후 닉네임이 업데이트되어도 TextField에 반영되지 않을 수 있습니다.
🐛 권장 수정
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
val textFieldState = rememberTextFieldState(uiState.userInfo.nickname)
+LaunchedEffect(uiState.userInfo.nickname) {
+ if (textFieldState.text.toString() != uiState.userInfo.nickname) {
+ textFieldState.setTextAndPlaceCursorAtEnd(uiState.userInfo.nickname)
+ }
+}🤖 Prompt for AI Agents
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt`
at line 74, 현재 textFieldState =
rememberTextFieldState(uiState.userInfo.nickname)는 초기 composition에서만 초기값을 잡아
네트워크로 닉네임이 바뀌어도 반영되지 않습니다; 수정은 두 가지 중 하나로 하세요: 1) remember의 key로
uiState.userInfo.nickname을 사용해 textFieldState를 재생성(예:
remember(uiState.userInfo.nickname) {
rememberTextFieldState(uiState.userInfo.nickname) })하거나 2) 텍스트 값을 동기화하는 방식으로
LaunchedEffect(uiState.userInfo.nickname) 내부에서 textFieldState의 업데이트 메서드(예:
setText 또는 해당하는 변경 API)를 호출해 외부 닉네임 변경 시 TextField 상태를 갱신하세요; 대상 심볼:
rememberTextFieldState, textFieldState, uiState.userInfo.nickname,
LaunchedEffect.
ikseong00
left a comment
There was a problem hiding this comment.
Q1. MyPageContract, LoginContract 에서, Init 에 해당하는 Intent 에 Enter~~Screen 대신 행동에 의한 결과(?)에 대한 네이밍을 사용하셨는데, 화면 진입에 해당하는 행동이지만 그에 따른 핸들링함수는 다르다고 생각합니다.
상황에 맞게 사용하는 것이 괜찮을까요??
Q2. 카카오 SDK 의 회원탈퇴, 로그아웃은 추후 구현 예정인가용??
Q3. 토큰 캐시 제거를 사용하는 케이스에서
- 토큰 만료 시
- 새로운 토큰 저장 시
- 로그아웃/회원탈퇴 시
이 경우에authCacheManager.invalidateTokenCache()를 선제적으로 호출하면 어떨까요??
그러면 각 ViewModel 에서의 처리도 필요 없을 것 같아 보입니다.
| "${context.packageManager.getPackageInfo(context.packageName, 0).versionName}" | ||
| } | ||
|
|
||
| viewModel.store.sideEffects.collectWithLifecycle { effect -> |
There was a problem hiding this comment.
이 부분 놓쳤는데 다른 코드들처럼 sideEffect 로 통일하는게 어떨까요??
There was a problem hiding this comment.
LaunchedEffect(Unit) {
val appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: ""
viewModel.store.onIntent(MyPageIntent.SetAppVersion(appVersion))
}
MypageScreen에서 앱버전 조회 후 Intent를 전달해 MypageState의 appVersion을 업데이트하도록 변경했습니다. e83a514
이제보니 사용자 정보 조회, 앱버전 조회, 권한 확인 세가지에 대해서 최초 1회 조회할 때에는 각각 조회하지 않고, MypageViewmodel의 initialFetchData를 통해 한번에 조회해두는게 좋을 것 같네요. 해당 부분도 차차 수정하겠습니다!
말씀하신 것처럼 3가지를 선언하는 건 어떨까요??
sealed interface EditProfileImageType {
data class OriginalImageUrl(val url: String): EditProfileImageType
data object Default: EditProfileImageType
data class ImageUri(val uri: Uri): EditProfileImageType
}
val shouldUpdate = state.type != EditProfileImageType.OriginalImageUrl
if(shouldUpdate) // 이미지 업데이트 로직아래 2번과도 관련이 있을 것 같습니다.
ViewModel 에서의 context 참조를 피하고자 사이드이펙트로 Request 를 요청하는 형식으로 해봤습니다. is MyPageEffect.IMAGE -> {
val request = ImageRequest.Builder(context)
.data(sideEffect.url)
.build()
context.imageLoader.execute(request)
}기존에는 뷰모델에서 하지만 이 경우(Screen 에서 Request)에 ImageRequest 를 하는 동안에 if (isProfileImageChanged) {
postSideEffect(MyPageEffect.IMAGE(user.profileImageUrl))
}
reduce {
copy(
isLoading = false,
selectedProfileImage = SelectedProfileImage.NoChange,
userInfo = user,
)
}
postSideEffect(MyPageEffect.NavigateBack)Screen_recording_20260205_165451.mp4그래서 displayProfileImage 를 OriginalImageUrl (기존 NoChange) 일 경우엔 아무 수정도 없이 합니다. var displayProfileImage by remember {
mutableStateOf<Any?>(uiState.userInfo.profileImageUrl)
}
// Original일 때는 스킵, 나머지만 반영
LaunchedEffect(uiState.profileImageState) {
when (uiState.profileImageState) {
is EditProfileImageType.OriginalImageUrl -> { }
is EditProfileImageType.ImageUri -> displayProfileImage = uiState.profileImageState.uri
EditProfileImageType.Default -> displayProfileImage = R.drawable.image_empty_profile_image
}
}이 방법으로 한다면 ViewModel 에서의 ImageRequest 를 Screen 으로 뺄 수 있고, 깜빡임 효과도 제거할 수 있습니다. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In
`@core/data/src/main/java/com/neki/android/core/data/repository/impl/AuthRepositoryImpl.kt`:
- Around line 30-32: The function AuthRepositoryImpl.withdrawAccount declares
suspend fun withdrawAccount(): Result<Unit> but runSuspendCatching currently
returns a BasicNullableResponse<Unit> from authService.withdrawAccount(),
causing a type mismatch; inside the runSuspendCatching block (or immediately
after), unwrap the BasicNullableResponse<Unit> (e.g., access its .data or map
the response) and convert/return a Result<Unit> (or Result.success(Unit) when
data indicates success, or propagate the error) so the function signature
matches; locate runSuspendCatching and authService.withdrawAccount() in
AuthRepositoryImpl to implement the mapping/unwrapping.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt`:
- Around line 45-58: MyPageEffect.NavigateBack and MyPageEffect.NavigateToLogin
are currently no-ops inside the viewModel.store.sideEffects.collectWithLifecycle
block, so navigation is never performed; update the when-handling for these
cases to invoke the proper navigation callbacks (e.g., call an existing
navigateBack() or navigateToLogin() function or forward the effect to the parent
via the same mechanism used by navigateToProfile() / navigateToPermission()), or
propagate the effect so the host handles it, ensuring NavigateBack and
NavigateToLogin are no longer ignored.
🧹 Nitpick comments (1)
feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt (1)
25-31: ViewModel에서 Context/Coil 실행은 UI 사이드이펙트로 분리 권장.
@ApplicationContext주입과imageLoader.execute는 ViewModel을 플랫폼에 결합시켜 테스트/재사용성을 낮춥니다. 프리캐시가 필요하다면MyPageEffect로 URL을 방출하고 Screen에서 처리하는 구조가 더 안전합니다.Also applies to: 157-161
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt`:
- Around line 128-130: The onFailure branch in MyPageViewModel.kt currently only
clears loading via reduce { copy(isLoading = false) } and lacks user feedback;
update the onFailure handler in the failing use-case callback (the block
containing reduce { copy(isLoading = false) }) to surface an error to the UI by
either setting an error field in the view state (e.g., reduce { copy(isLoading =
false, errorMessage = someUserFriendlyMessageFrom(it)) }) or invoking the
existing UI event/side-effect mechanism (e.g., emit a Toast/UiEvent) so
network/other errors are shown to the user.
🧹 Nitpick comments (2)
feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt (1)
25-31: ViewModel에서 Context 직접 사용 회피 권장PR 논의에서 언급된 것처럼, ViewModel에서
Context를 직접 사용하는 대신 Side Effect를 통해 UI 레이어에서 이미지 캐싱을 처리하는 것이 더 나은 아키텍처입니다.@ApplicationContext는 메모리 누수 위험은 없지만, ViewModel의 테스트 용이성과 관심사 분리 측면에서 개선의 여지가 있습니다.ikseong00 리뷰어의 제안처럼
MyPageEffect.CacheImage(url)같은 Side Effect를 발행하고 Screen에서 처리하는 방식을 고려해보세요.feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt (1)
27-27: 네이밍 불일치:EnterMypageScreen→EnterMyPageScreen파일명(
MyPageContract.kt) 및 다른 타입들(MyPageState,MyPageIntent,MyPageEffect)에서 "MyPage"로 표기하고 있으나, 해당 Intent만 "Mypage"(소문자 p)로 작성되어 있습니다. 일관성을 위해EnterMyPageScreen으로 수정하는 것을 권장합니다.🔧 네이밍 수정 제안
- data object EnterMypageScreen : MyPageIntent + data object EnterMyPageScreen : MyPageIntent
익성님이 작업하신 부분들 처럼 모든 Contract에 대해 Init/최초 진입을 의미하는 Intent를
오.. 카카오 SDK의 로그아웃과 회원탈퇴는 누락되었습니다... 추가했습니다. 로그아웃의 경우 API 호출이 없으나 탈퇴는 API 호출이 있어 저희 서버 먼저 탈퇴 후 카카오 SDK 연결을 취소시켰습니다.
일부에서 선제적으로 호출하더라도 RefreshToken이 만료되는 해당 부분에서 결국 null을 반환하여 메모리에 null이 캐싱된 이후 로그인을 다시하면 캐싱된 null 토큰을 사용하는 현상이 있었기 때문입니다. 그래서 LoginViewModel에서 새로운 토큰을 저장할 떄 다만, 캐시를 수동으로 제거하는 것은 Ktor 문서에서 설명되어 있듯이 토큰의 주기가 굉장히 짧은 현재 저희 개발서버에서 대응하기 위함이고, 이후에 내부적으로 토큰캐싱 속성 제거를 지원하는 Ktor 버전을 3.4.0으로 올리면 해당 구문을 제거할 수 있습니다. |
토큰이 업데이트될 때 기존 토큰 캐시가 삭제되어야하기 때문에 현재 ViewModel 에서
class TokenRepositoryImpl @Inject constructor(
private val authCacheManager: AuthCacheManager,
) {
override suspend fun saveTokens(...) {
authCacheManager.invalidateTokenCache()
...
}
// clearToken 도 동일
} |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Fix all issues with AI agents
In
`@core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt`:
- Around line 14-29: The code force-unwraps token.idToken in both
UserApiClient.instance.loginWithKakaoTalk and loginWithKakaoAccount which can
cause NPE if idToken is null and also misses the case where error==null &&
token==null; refactor by extracting a single callback handler (e.g.,
handleKakaoLoginResult(token, error, onSuccess, onFailure)) used by both
loginWithKakaoTalk and loginWithKakaoAccount that: 1) checks if error != null ->
call onFailure(error.message ?: "카카오 로그인에 실패했습니다."), 2) else if token?.idToken
!= null -> call onSuccess(token.idToken), 3) else -> call onFailure with a clear
message about missing idToken (e.g., "idToken is null; ensure OIDC/openid scope
is enabled"); this removes duplication and avoids using token.idToken!!.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt`:
- Line 12: The initial profileImageState is set to
EditProfileImageType.OriginalImageUrl("") but fetchInitialData's onSuccess
doesn't update it, so the UI won't reflect the server image; update the
ViewModel's fetchInitialData onSuccess reduce block to also set
profileImageState to EditProfileImageType.OriginalImageUrl(user.profileImageUrl)
(i.e., in the reduce { copy(...) } call alongside isLoading and userInfo) so the
ViewState's profileImageState is synchronized with the fetched user data.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt`:
- Around line 146-149: The current MyPageViewModel branch calls
uploadProfileImageUseCase(uri = uri) when isProfileImageChanged is true but
casts profileImageState with as? EditProfileImageType.ImageUri which yields null
for EditProfileImageType.Default, causing an unintended null URI; update the
logic in MyPageViewModel so you explicitly branch on profileImageState (check
for EditProfileImageType.ImageUri vs EditProfileImageType.Default) — when
ImageUri present call uploadProfileImageUseCase with that non-null uri, when
Default either skip calling uploadProfileImageUseCase or call it with an
explicit sentinel/flag meaning “reset to default” and add a brief comment
documenting the chosen behavior (or, if null is intended, add a clarifying
comment explaining that uploadProfileImageUseCase(uri = null) denotes
default-reset).
- Around line 52-55: The ClickBackIcon branch sets profileImageState to
EditProfileImageType.OriginalImageUrl but the EditProfileScreen's LaunchedEffect
doesn't handle that case, so update the LaunchedEffect to handle
EditProfileImageType.OriginalImageUrl by setting displayProfileImage =
uiState.profileImageState.url (reference: MyPageIntent.ClickBackIcon,
profileImageState, EditProfileImageType.OriginalImageUrl, displayProfileImage,
LaunchedEffect in EditProfileScreen); also treat empty string URLs as null
before assigning (or map "" -> null) so AsyncImage's model ?: fallback will use
the fallback drawable when state.userInfo.profileImageUrl is empty.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt`:
- Around line 85-95: displayProfileImage is initialized with remember {
mutableStateOf(uiState.userInfo.profileImageUrl) } so it never updates when
userInfo.profileImageUrl changes, and the LaunchedEffect ignores the
OriginalImageUrl case; update the logic so OriginalImageUrl sets
displayProfileImage from the latest uiState.userInfo.profileImageUrl (or make
the remembered state depend on uiState.userInfo.profileImageUrl by using
remember(uiState.userInfo.profileImageUrl) { mutableStateOf(...) }) and in the
LaunchedEffect(uiState.profileImageState) handle
EditProfileImageType.OriginalImageUrl by assigning displayProfileImage =
uiState.userInfo.profileImageUrl so the UI reflects server-loaded image updates.
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt`:
- Around line 46-57: The logout/unlink callbacks currently only call
navigateToLogin on onSuccess, causing the app to hang if kakaoAuthHelper.logout
or kakaoAuthHelper.unlink fails after tokenRepository.clearTokens() ran; update
the handlers in ProfileSettingScreen so both onSuccess and onFailure call
navigateToLogin (and still log the error in onFailure via Timber.e), i.e.,
change the onFailure lambdas for kakaoAuthHelper.logout and
kakaoAuthHelper.unlink to call navigateToLogin() after logging the error to
ensure the user is always routed to the login screen.
🧹 Nitpick comments (3)
core/common/build.gradle.kts (1)
13-13:api대신implementation으로 변경 검토
KakaoAuthHelper의 public API는 Kakao SDK 타입을 노출하지 않고String,Unit,Context만 사용합니다.api로 선언하면core/common에 의존하는 모든 모듈의 컴파일 클래스패스에 Kakao SDK가 불필요하게 노출되어, 빌드 시간 증가와 의도치 않은 의존성 누출이 발생할 수 있습니다.- api(libs.kakao.user) + implementation(libs.kakao.user)core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt (1)
6-8:Context보유에 대한 메모리 누수 고려
KakaoAuthHelper가Context를 필드로 보유하고 있는데, 호출부(LoginScreen.kt)에서remember { KakaoAuthHelper(context) }로 생성하고 있어 Activity Context가 캡처됩니다. 현재 구조에서는 Composable 생명주기와 함께 해제되므로 큰 문제는 아니지만, 향후 이 헬퍼가 ViewModel이나 싱글턴에서 사용될 경우 Activity 누수 위험이 있습니다.context가login()에서만 사용되므로 필드 대신 메서드 파라미터로 전달하는 방식도 고려해 볼 수 있습니다.feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt (1)
182-197: 회원 탈퇴 실패 시 사용자 피드백이 없습니다.
onFailure에서Timber.e(it)로 로그만 남기고isLoading = false로 전환하지만, 사용자에게는 아무런 피드백이 없습니다. 탈퇴는 중요한 작업이므로 실패 시 최소한 토스트나 스낵바를 표시하는 것이 좋습니다.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt`:
- Around line 182-197: withdrawAccount's onFailure only clears loading and logs
the error, so the user gets no feedback; update the onFailure block to call
postSideEffect with an appropriate MyPageEffect (e.g., MyPageEffect.ShowError or
MyPageEffect.ShowToast) carrying a user-facing message (or the
throwable.message) and keep Timber.e(it) and reduce { copy(isLoading = false) }
as is; reference withdrawAccount, onFailure, postSideEffect, and MyPageEffect
when making the change so the UI can display the error to the user.
- Around line 152-174: The onSuccess handler of userRepository.getUserInfo()
currently only posts MyPageEffect.PreloadImageAndNavigateBack when
isProfileImageChanged is true, so when only the nickname changed
(isNicknameChanged==true and isProfileImageChanged==false) no navigation effect
is emitted; update the onSuccess block in MyPageViewModel.kt so that after
reducing state you post MyPageEffect.PreloadImageAndNavigateBack when
isProfileImageChanged is true, otherwise if isNicknameChanged is true post
MyPageEffect.NavigateBack (ensure you reference the existing flags
isProfileImageChanged and isNicknameChanged and the effects
MyPageEffect.PreloadImageAndNavigateBack / MyPageEffect.NavigateBack in the
userRepository.getUserInfo() onSuccess path).
🧹 Nitpick comments (1)
feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt (1)
80-81:EditProfileScreen의 가시성을internal로 변경하는 것을 권장합니다.
EditProfileRoute는internal로 선언되어 있지만,EditProfileScreen은public입니다. 모듈 외부에서 직접 사용될 필요가 없다면internal로 제한하는 것이 캡슐화에 더 적합합니다.♻️ 수정 제안
-fun EditProfileScreen( +internal fun EditProfileScreen(
|
위에서 말씀해주셨던 프로필 이미지 변경 로직 아이디어 반영했습니다. d0f2a1e 갤러리에서 선택한 프로필 이미지와 기존 상태를 동시에 표현하기 위해 덕분에 ViewModel 내 Context 제거할 수 있었고, 자연스럽게 동작하게끔 개선할 수 있었습니다! 다만, reduce와 ImageRequest가 병렬로 진행될 시 이미 로딩다이어로그는 화면에서 제거되었지만 바로 뒤로가지 못하고, 그래서 프로필 이미지 변경 후 NavigateBack 이펙트 발생을 제거했고, 근데 이렇게 수정하니 닉네임만 변경했을 때 뒤로가지 못해서 닉네임만 변경한 경우를 구분해 |
이해했습니다. 제안해주신 방향이 더 좋겠네요! 반영했습니다. b6062cb |
45c1d16 to
b6062cb
Compare
🔗 관련 이슈
📙 작업 설명
🧪 테스트 내역 (선택)
📸 스크린샷 또는 시연 영상 (선택)
KakaoTalk_Video_2026-02-04-23-43-21.mp4
KakaoTalk_Video_2026-02-04-23-43-15.mp4
KakaoTalk_Video_2026-02-04-23-45-57.mp4
KakaoTalk_Video_2026-02-04-23-50-31.mp4
KakaoTalk_Video_2026-02-04-23-52-16.mp4
KakaoTalk_Video_2026-02-04-23-54-36.mp4
💬 추가 설명 or 리뷰 포인트 (선택)
레전드 고봉밥 죄송합니다...
HttpClient속성 설정 중 RefreshToken이 만료되어 로그인 화면으로 이동할 때BearerAuthConfig에 null이 들어가게 되는데 Ktor는 인증 문서에 따르면 내부적으로 토큰을 로드하는데 캐싱된 토큰을 가져오기 때문에 로그인 이후에 갱신된 토큰에 접근하지 못하고 캐싱된 null에 접근하는 이슈를 확인했습니다.그래서 저희처럼 만료 주기가 짧은 경우 캐시를 제어할 수 있도록 3.4.0 버전에서 업데이트가 되었지만 Ktor 버전을 3.4.0으로 올리려 했지만 kotlin, hilt, agp, serialization 등 관련된 다른 버전도 모두 올려주어야 해서 지금 버전을 올리기에는 어렵다고 판단해 소셜로그인 후 캐시를 수동으로 제거하기 위해
AuthCacheManager를 정의했습니다.Q1. 프로필 이미지 변경 시에 서버에서 내려주는 이미지는 String(url)이고(기본 이미지도 url), 앨범에서 선택 시 Uri, 기본 이미지로 설정 시 null 총 3가지 타입에 대해서 상태를 유지해야 하는데 해당 방법 외에 조금 더 나은 아이디어가 있으실까요?
Q2. 프로필 이미지를 변경한 후 이전화면으로 돌아갈 떄 새로운 이미지 url을 로드하는 동안 잠시 아무것도 보이지 않는 현상이 있습니다. 그렇기 때문에 사용자 정보 조회 API를 통해 조회한 새로운 프로필 이미지 url을 캐싱해두고 이전 화면으로 돌아가려 합니다.
캐싱하는 과정에서 context가 필요한데 screen에서 캐싱 요청/캐싱 완료를 의미하는 Intent/Effect를 정의하여 사용자 정보 조회 API -> 캐싱 요청 Effect -> 캐싱 완료 intent -> 뒤로가기 Effect -> 뒤로가기. 의 순서로 진행을 할지, 혹은 Viewmodel에 Context를 주입하여
캐싱 완료 후 바로 뒤로가기 Effect를 발생시킬지 고민이 되었는데 현재 ViewModel에 context를 주입하는 방법으로 개발하였습니다. 어떤 방식이 나을지, 혹은 떠오르는 더 좋은 방법이 있으실까요?
캐싱 후 프로필 이미지 url을 로드하는 현상이 제거된 녹화영상입니다.
KakaoTalk_Video_2026-02-05-01-04-00.mp4
Summary by CodeRabbit
릴리스 노트
New Features
Improvements